Support multiple external IPs in blueprint zone type - #11236
Conversation
bnaecker
commented
Sep 3, 2026
- Add support for multiple EIPs to the blueprint zone types for Nexus, External DNS, and Boundary NTP zones.
- Add some newtypes and wrappers to support lists of these or up to 2 of them for the SNAT case of Boundary NTP.
- Add a test that the full blueprint with multiple addresses round-trips through the database.
- Planner still emits exactly one address in all these cases, this is only the structural change to support multiple addresses.
- Update lockstep OpenAPI docs
36eb2df to
3a6bc8d
Compare
- Add support for multiple EIPs to the blueprint zone types for Nexus, External DNS, and Boundary NTP zones. - Add some newtypes and wrappers to support lists of these or up to 2 of them for the SNAT case of Boundary NTP. - Add a test that the full blueprint with multiple addresses round-trips through the database. - Planner still emits exactly one address in all these cases, this is only the structural change to support multiple addresses. - Update lockstep OpenAPI docs - Closes #9288
3a6bc8d to
9c66fa5
Compare
| .map(|kind| ServiceZoneNatEntry { | ||
| zone_id: zone_config.id, | ||
| sled_underlay_ip: *get_sled_address(sled_subnet).ip(), | ||
| nic_mac, | ||
| vni, | ||
| kind, | ||
| }) | ||
| .collect::<Vec<_>>(); |
There was a problem hiding this comment.
Tiny nit - I don't think we need to allocate an intermediate Vec here; we can return the iterator directly
| .map(|kind| ServiceZoneNatEntry { | |
| zone_id: zone_config.id, | |
| sled_underlay_ip: *get_sled_address(sled_subnet).ip(), | |
| nic_mac, | |
| vni, | |
| kind, | |
| }) | |
| .collect::<Vec<_>>(); | |
| .map(move |kind| ServiceZoneNatEntry { | |
| zone_id: zone_config.id, | |
| sled_underlay_ip: *get_sled_address(sled_subnet).ip(), | |
| nic_mac, | |
| vni, | |
| kind, | |
| }); |
| ), | ||
| dns_addresses: zone | ||
| .dns_addresses | ||
| .into_external_dns_addrs_or_panic(), |
There was a problem hiding this comment.
Hopefully this is just a naming nit (haven't reviewed the implementation of this method yet), but I don't think *_or_panic() should be a thing. Either:
- this conversion can fail, in which case it should return a result
- this conversion can't fail, in which case it shouldn't need an
_or_panic()suffix
It's entirely possible the method contains assertions or unwraps that we know can't fail, but that'd fall into the second category, I think - it'd be an implementation detail that doesn't need to worry the caller.
There was a problem hiding this comment.
This is a good example of the allergy to From impls I mentioned in the sibling comment :)
So the only reason this exists is because we convert from BlueprintZoneType to OmicronZoneType here, which has this:
omicron/nexus/types/src/deployment/zone_type.rs
Lines 300 to 302 in ebebdfe
What we're really doing here is converting between two types that should have the same invariants, but are still two types that could drift. I have the proptest in here to catch that, but I was hoping this would make it more clear that there's a technical possibility of failure.
In any case, I think this is the second case you mentioned, so a From impl or the named method without _or_panic() is better.
| /// IP. | ||
| // | ||
| // NOTE: It's important that we continue to derive Ord and Eq. They're used in | ||
| // those trait implementations for the newtype `OmicronZoneExternalFloatingIps`. |
There was a problem hiding this comment.
Really small nit but I'm not sure this is a useful note - if someone removes these derives, won't they get a compilation error from where they're used?
There was a problem hiding this comment.
Yeah you might be right. While implementing this, I definitely confused myself into a corner trying to get Ord and also IdOrdItem to match, but that's not required. Anyway, I'll remove this, thanks.
| // | ||
| // NOTE: It's important that we continue to derive Ord and Eq. They're used in | ||
| // those trait implementations for the newtype | ||
| // `OmicronZoneExternalFloatingAddrs`. |
| /// | ||
| /// ## Implementation notes | ||
| /// | ||
| /// `OmicronZoneNetworkResources` consists of two 1:1:1 "trijective" maps: |
There was a problem hiding this comment.
Is this type still meaningful / correct? IIUC it only allows one external IP per zone ID
| pub fn external_networking( | ||
| &self, | ||
| ) -> Option<(OmicronZoneExternalIp, &NetworkInterface)> { | ||
| ) -> Option<(Vec<OmicronZoneExternalIp>, &NetworkInterface)> { |
There was a problem hiding this comment.
Returning a documented-as-nonempty Vec doesn't feel great, especially since a bunch of the callers discard it and only care about the NIC. I guess changing it to return an impl Iterator would avoid the unnecessary allocation but wouldn't fix the "I have to assert that it's not empty" bit. Wondering if we should bite the bullet and pull in (or generate our own) nonempty collections. Probably not worth it for just this though. Sorry for the rambling.
There was a problem hiding this comment.
I have needed a NonEmptyVec<T> many many times. I agree we should write one or use a good crate.
There was a problem hiding this comment.
Would a newtype be better in this one case? There is also at least one widely-downloaded crate for this. I can't speak to its quality yet :)
| /// necessary for blueprint planning, and requires that the zone have a single | ||
| /// IP. |
There was a problem hiding this comment.
"requires that the zone have a single IP" isn't right anymore
| /// necessary for blueprint planning, and requires that the zone have a single | ||
| /// IP. |
There was a problem hiding this comment.
(same stale single IP comment)
|
|
||
| // NOTE: this is the *internal* DNS underlay address, held in | ||
| // `second_service_ip` / `second_service_port`. External DNS's external | ||
| // address comes from `external_ip` above. |
There was a problem hiding this comment.
- // address comes from `external_ip` above.
+ // addresses come from `external_ip_rows`.| /// The service vNIC providing outbound connectivity using OPTE. | ||
| pub nic: NetworkInterface, | ||
| pub external_ip: OmicronZoneExternalSnatIp, | ||
| /// The source NAT configuration (one address per IP family). |
There was a problem hiding this comment.
Does "one address per IP family" imply it always has both? But "up to one address per IP family" implies it could be no addrs at all. Yuck. Maybe delete the parenthetical entirely? 😅
There was a problem hiding this comment.
Yeah, this always sucks to explain. It is: up to one address per family, and always at least one address.